Skip to content
created by Aha00aAha00a at 2026-06-24
last modified by Aha00aAha00a at 2026-09-19
revision: 11

Dev Api

외부 자동화 도구가 AhaWiki 페이지를 직접 수정할 수 있도록 사용자 개인 API Key 인증을 추가했다.

세션 쿠키와 reCAPTCHA 없이 Authorization: Bearer <key> 헤더로 페이지를 읽고 저장할 수 있는 API를 제공한다.

페이지 히스토리에는 편집자가 기존 사용자로 기록되며, API Key를 통한 편집은 Page.viaApi = TRUE로 표시한다.

1. 설계 결정

  • API Key는 기존 사용자 계정에 직접 연결한다. 자동화 전용 계정을 별도로 만들지 않는다.
  • API Key로 저장한 revision은 Page.viaApi = TRUE로 기록한다.
  • 어느 key로 저장했는지는 Page.userApiKey nullable FK로 함께 기록한다.
    • viaApi(불변 사실)와 userApiKey(삭제/이름변경 가능한 엔티티 참조)는 서로 다른 질문에 답하므로 둘 다 유지한다.
    • 불변식: userApiKey IS NOT NULL이면 viaApi = TRUE다. 역은 성립하지 않는다(viaApi = TRUE && userApiKey = NULL은 "API로 저장됐지만 그 키는 이후 삭제됨").
    • key를 hard delete해도 viaApi 사실이 보존되도록 ON DELETE SET NULL을 쓴다. 현재 key는 soft-delete(dateRevoked)만 하므로 평상시 FK는 끊기지 않는다.
  • UserApiKey.name은 사람이 읽을 수 있는 키 이름이다. 화면/응답에는 join으로 현재 이름을 노출하고, key가 없으면 이름 없이 viaApi만 표시한다.
  • API Key 원문은 DB에 저장하지 않고 SHA-256 hash만 저장한다. 원문 key는 생성 응답에서 한 번만 보여준다.
  • AhaWiki API는 Bearer 인증을 사용하므로 CSRF token과 reCAPTCHA를 요구하지 않는다.
  • AhaWiki API의 읽기/쓰기 권한은 기존 WikiPermission 규칙과 key 소유자 사용자의 권한을 그대로 따른다.
  • API Key 관리는 사용자가 Account Settings에서 직접 한다. Admin은 전체 key 조회와 강제 폐기만 할 수 있다.

2. Data Model

2.1. UserApiKey

UserApiKey 테이블은 사용자별 API Key 메타데이터와 hash를 저장한다.

  • seq BIGINT AUTO_INCREMENT PRIMARY KEY
  • user INT NOT NULL — User.seq FK
  • keyHash VARCHAR(64) NOT NULL UNIQUE — SHA-256 hex
  • keyPrefix VARCHAR(32) NOT NULL — 목록에서 key를 식별하기 위한 prefix
  • name VARCHAR(255) NOT NULL — 사람이 읽을 수 있는 키 이름 (이전 label에서 rename)
  • dateInserted DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  • dateLastUsed DATETIME NULL
  • dateRevoked DATETIME NULL
  • INDEX (user, dateRevoked) — 사용자별 key 목록 조회

keyHash UNIQUE가 인증 조회 인덱스 역할을 하므로 별도 INDEX (keyHash)는 만들지 않는다.

2.2. Page.viaApi

Page 테이블에 viaApi BOOLEAN NOT NULL DEFAULT FALSE 컬럼을 추가했다.

  • 일반 웹 편집은 기본값 FALSE다.
  • API Key 저장만 TRUE로 기록한다.
  • Page, PageWithoutContent, row parser, history 조회, insert SQL에 모두 viaApi를 포함한다.
  • PageLogic.insert는 viaApi: Boolean = false 기본값을 받고, API Key 저장 시 true를 전달한다.

2.3. Page.userApiKey

Page 테이블에 userApiKey BIGINT NULL 컬럼과 Page_UserApiKey_seq_fk FOREIGN KEY (userApiKey) REFERENCES UserApiKey (seq) ON DELETE SET NULL을 추가했다.

  • 웹 편집은 NULL이고, API Key 저장만 해당 key의 seq를 기록한다.
  • Page에는 userApiKey: Option[Long] seq만 둔다. PageWithoutContent는 history 표시용으로 join한 userApiKeyName: Option[String]도 갖는다.
  • PageLogic.insert는 userApiKey: Option[Long] = None 파라미터를 받는다.
  • ApiV1.savePage / renamePage는 withApiUserAndKey로 인증 key를 받아 userApiKey = Some(apiKey.seq)를 전달한다.
  • 키 이름은 그때그때 UserApiKey 에서 조회한다 — 변경 목록은 LEFT JOIN, 페이지 목록·메타·본문은 UserApiKey.selectNamesBySeqs. 비정규화 스냅샷은 두지 않는다.

3. 인증과 권한

SessionLogic.getApiKeyUser(request)가 Authorization: Bearer <key> 헤더를 읽고 raw key를 SHA-256으로 hash한 뒤 UserApiKey에서 활성 key를 조회한다.

인증 성공 시:

  • dateLastUsed를 갱신한다.
  • key 소유자의 User.SessionUser를 만든다.
  • primary email을 함께 담아 기존 email 기반 permission과 호환한다.

getApiKeyWithUser는 인증 key(UserApiKey)와 SessionUser를 함께 반환하고, getApiKeyUser는 그중 사용자만 돌려준다.

저장/이름변경처럼 userApiKey를 기록해야 하는 endpoint는 withApiUserAndKey로 둘을 함께 받는다.

AhaWiki API에서는 RequestWrapper.forUser(user)로 인증 사용자를 ContextWikiPage와 WikiPermission에 전달한다.

이 처리가 없으면 인증은 성공해도 권한 계산이 익명 사용자 기준으로 동작할 수 있다.

4. Bearer 인증은 v1 경로에서만 동작한다

Authorization: Bearer <key> 를 읽는 코드는 SessionLogic.getApiKeyWithUser 하나뿐이고, 그것을 부르는 것은 ApiV1 뿐이다.

그 밖의 endpoint 는 어떤 key 를 붙여도 key 를 보지 않는다.

그런데 헤더를 그냥 무시하지도 않는다. CSRF filter 를 건너뛸 경로를 정하는 것은 app/Filters.scala 이고, 거기서 빠지는 경로는 CSRF 검사를 받는다.

Play 의 기본값 play.filters.csrf.header.protectHeaders 는 Cookie 나 Authorization 이 붙은 요청만 검사 대상으로 삼는데, 이 저장소는 conf/base.conf 에서 그 값을 바꾸지 않는다.

그래서 검사 대상이 아니던 요청에 Authorization 헤더를 붙이는 순간 CSRF token 이 필요해지고, token 이 없으면 403 이다.

이 403 은 key 가 틀렸다는 뜻이 아니다. key 는 읽히지도 않는다.

유효한 key 든 아무 문자열이든 결과가 같고, 헤더를 빼면 같은 요청이 그대로 성공한다.

응답 본문이 Play 의 기본 오류 페이지라 제목에 Unauthorized 가 찍히는 것도 헷갈리는 이유다 — 인증이 거절된 것이 아니다.

2026-09-19 에 https://aha00a.com 에서 확인한 결과:

요청

Authorization

결과

POST /api/renderAhaMark/:pageName

없음

200

POST /api/renderAhaMark/:pageName

아무 문자열

403

POST /api/renderAhaMark/:pageName

유효한 key

403

GET /api/v1/pages

없음

401

GET /api/v1/pages

유효한 key

200

상태 코드로 구분하면, AhaWiki API 는 key 가 없거나 잘못됐을 때 401 을 주고 권한이 없을 때 403 을 준다(위 «인증과 권한»).

AhaWiki API 밖에서 받는 403 은 둘 중 어느 쪽도 아니라서, key 를 새로 발급하거나 바꿔도 사라지지 않는다.

4.1. 예전 endpoint 를 부르는 법

  • 스크립트에서는 Authorization 헤더 없이 부른다. /api/renderAhaMark/... 같은 예전 endpoint 는 원래 그렇게 쓰는 것이다.
  • 브라우저는 Cookie 를 늘 보내므로 헤더를 빼는 것만으로는 부족하다. GET /api/csrf 로 token 을 받아 Csrf-Token 헤더에 담아야 한다 — AhaWiki.Kanban.js 가 그렇게 한다.
  • 이 403 응답에는 Set-Cookie: PLAY_SESSION=; Max-Age=0 이 붙는다. 같은 요청이 200 일 때는 붙지 않는다. 브라우저에서 부른 경우 세션 쿠키가 지워지므로, 증상이 403 하나로 끝나지 않는다.

4.2. 하지 않기로 한 것

  • protectHeaders 에서 Authorization 을 빼지 않았다. 그러면 403 은 사라지지만 요청이 인증되지도 않는다 — 예전 endpoint 는 key 를 읽지 않으니 여전히 익명 요청이고, 대신 Play 의 기본 보호 범위만 좁아진다.
  • CSRF 우회 경로를 넓히지 않았다. 우회해도 되는 것은 Bearer 로 인증하는 endpoint 뿐이고, 나머지는 세션 쿠키로 인증하므로 CSRF 검사가 필요하다.

5. API와 UI

5.1. AhaWiki API

외부 사용 설명서는 Api에 둔다.

  • GET /api/v1/page/*nameEncoded
  • POST /api/v1/page/*nameEncoded
  • GET /api/v1/pages
  • POST /api/v1/pages/metadata
  • GET /api/v1/changes
  • POST /api/v1/rename
  • DELETE /api/v1/page/*nameEncoded

저장 API는 JSON body의 revision, text, comment, minorEdit를 사용한다.

revision이 최신과 다르면 409 Conflict를 반환한다.

현재 API 저장은 PageLogic.insert(..., viaApi = true), cache invalidate, page calculation enqueue까지만 수행한다.

웹 편집의 websocket broadcast와 Telegram 알림은 보내지 않는다.

Telegram 알림은 minorEdit와 viaApi 저장을 제외한다.

5.2. AhaWikiDoc Sync 지원

AhaWikiDoc sync를 위해 AhaWiki API를 보강했다.

  • 페이지 목록/메타 API는 name, revision, dateTime, isMinorEdit, viaApi, userApiKeyName, size, contentHash를 반환한다.
    • GET /api/v1/pages 는 2026-09-10 까지 userApiKeyName 을 항상 null 로 줬다 — 목록 SQL 이 userApiKey 컬럼을 읽지 않아 Page 의 기본값 None 이 그대로 나갔고, 메타·본문 endpoint 만 이름을 답했다. 지금은 읽고, ApiV1Spec 이 목록에서도 이름을 단언한다.
  • Batch metadata API는 여러 page name의 revision, dateTime, hash를 한 번에 조회한다.
  • 최근 변경 API는 API Key 인증으로 page prefix, timestamp, page별 revision 기준 필터를 지원한다.
  • afterRevision은 페이지별 revision이므로 name으로 단일 페이지를 지정한 경우에만 허용한다.
  • 이름변경 API는 revision 확인 후 기존 페이지를 rename하고, 기존 이름에는 viaApi = true redirect page를 만든다.
  • 삭제 API는 revision과 confirm: true를 요구하며, 웹 삭제와 같은 정책으로 첨부파일도 삭제 처리한다.
  • sync state는 서버에 저장하지 않고 local manifest에 lastSyncedAt, page별 revision, dateTime, contentHash를 기록하는 방식을 권장한다.
  • 문서 rename sync는 delete+create가 아니라 POST /api/v1/rename으로 기존 page history를 보존한다.

5.3. Account Settings

Account Settings에 API Key 관리 섹션을 추가했다.

  • 내 API Key 목록
  • name 기반 key 생성
  • 생성 직후 plain text key 1회 표시와 복사 버튼
  • key 폐기 버튼

목록 조회에서는 plain text key를 반환하지 않고 keyPrefix만 보여준다.

이 화면은 Admin SPA가 아니라 위키 문서와 같은 껍데기를 쓴다. Account/settings.scala.html이 _base 레이아웃 안에서 wikiContent · limitWidth를 두르고, 표는 wikiTableSimple이다. Admin 화면(React · Mantine)과 다른 쪽을 고른 것이므로, 이 화면을 고칠 때 Admin 쪽 컴포넌트를 가져오면 톤이 어긋난다.

세션 기반 내부 API:

  • GET /api/account/ApiKeys
  • POST /api/account/ApiKeys
  • DELETE /api/account/ApiKeys/:seq

이 API는 로그인 세션과 CSRF token이 필요하다.

POST 응답에만 plain text key를 포함하고, 이후 조회에서는 keyPrefix만 반환한다.

5.4. Admin UI

Admin SPA에 /Admin/ApiKeys 화면을 추가했다.

  • 전체 API Key 목록
  • user nickname, name, key prefix, 생성일, 마지막 사용일, 폐기 상태
  • Admin 강제 폐기

세션 기반 내부 Admin API:

  • GET /api/Admin/ApiKeys
  • DELETE /api/Admin/ApiKeys/:seq

Admin 권한과 CSRF token이 필요하다.

6. 변경 이력 표시

viaApi는 사용자가 자동화 편집을 구분할 수 있도록 여러 화면과 API에 노출한다.

  • Api.change 응답에 viaApi 포함
  • ApiAdminReport.adminRecentChanges 응답에 viaApi 포함
  • Wiki history 화면에 minor edit, Via API 이모지 컬럼과 Show ViaApi 필터 추가
  • RecentChanges 매크로에 Include via API edits 토글과 minor edit, Via API 이모지 컬럼 추가
  • /api/change에 includeViaApi 파라미터 추가
  • Admin Recent Changes 페이지에 viaApi 포함/제외 토글과 minor edit, Via API 이모지 컬럼 추가
  • Admin Dashboard의 Recent Changes 요약 표에 minor edit, Via API 이모지 컬럼 추가

viaApi 편집은 어느 key였는지도 함께 보여준다. key 이름은 그때 UserApiKey 에서 조회한다(방법은 위 «Data Model» 의 Page.userApiKey). 폐기는 dateRevoked 를 채울 뿐 행을 지우지 않고 조회도 폐기 여부를 보지 않으므로, 폐기한 key 의 이름도 그대로 나온다. 이름이 빠지는 것은 행을 DB 에서 직접 지웠을 때뿐이다 — Page.userApiKey 의 FK 가 ON DELETE SET NULL 이라 viaApi 만 남는다.

  • Api.change, ApiAdminReport.adminRecentChanges, ApiV1.changes, 페이지 목록/메타(ApiV1.listPages / pageMetadata / getPage) 응답에 userApiKeyName 포함
  • Wiki history 화면의 Via API 컬럼에 key 이름을 함께 표시(있을 때)
  • RecentChanges 매크로는 각 revision comment에 [viaApi:<name>] prefix로 표시
  • Admin Recent Changes / Dashboard의 Via API 셀에 key 이름을 함께 표시(makeFlagCell의 detail 인자)

7. Security

  • API Key는 SecureRandom으로 32 bytes를 생성하고 Base64 URL-safe 문자열로 표시한다. 형식은 ahawiki_<token>이다.
  • DB에는 raw key를 저장하지 않는다.
  • 고엔트로피 API Key이므로 SHA-256 hash로 비교한다. bcrypt/Argon2 같은 slow hash는 요청마다 불필요한 지연을 만든다.
  • AhaWiki API는 CSRF filter를 우회한다. 어느 경로가 우회하는지는 app/Filters.scala가 정하고, 그 밖의 경로에 Authorization을 붙이면 어떻게 되는지는 위 «Bearer 인증은 v1 경로에서만 동작한다»에 있다.
  • 브라우저 세션 기반 Account/Admin API는 기존 CSRF 정책을 유지한다.
  • 별도 API Key 단위 rate limit은 1차 구현에 포함하지 않았다. 현재 전역 IP rate limit은 AhaWiki API에도 적용된다.

8. Tests

  • ApiV1Spec
    • API Key hash 저장
    • 유효 key 인증 성공
    • 폐기 key와 존재하지 않는 key 인증 실패
    • 읽기/쓰기 권한 403
    • API 읽기
    • API 저장과 viaApi = TRUE, userApiKey seq 기록, 읽기 응답의 userApiKeyName 노출
    • revision 충돌 409 Conflict
    • 페이지 목록/메타 조회
    • 최근 변경의 since, includeMinorEdit, includeViaApi, invalid since 검증
    • afterRevision을 단일 페이지에만 허용하는 정책 검증
    • API 이름변경과 redirect 생성
    • API 삭제와 첨부파일 삭제 표시
    • Account API 생성/목록/폐기
  • ApiV1FilterSpec
    • 실제 Filters 체인에서 /api/v1/ POST가 CSRF token 없이 Bearer 인증만으로 저장되는지 검증
  • UnitTestSuiteSpec
    • 테스트 schema 는 TestSchema 가 schema/schema.sql 덤프에서 만든다(2026-08-10 부터). viaApi, userApiKey 컬럼은 덤프를 갱신하면 따라온다 — 손으로 쓴 Page schema 는 이제 없다

9. 검증

관련 테스트:

  • sbt.bat "testOnly com.aha00a.controllers.ApiV1Spec"
  • sbt.bat "testOnly com.aha00a.controllers.ApiV1FilterSpec"

마지막 확인 시 ApiV1Spec 18개 테스트가 통과했다.

10. See Also

10.2. Similar Pages

Similar pages by cosine similarity. Words after page name are term frequency.

  • Same Wiki
    • 82.58% Api api(147:84), key(85:32), page(33:27), via(35:20), user(44:8), wiki(22:15), name(16:20), v1(19:16), aha(19:14), revision(8:19)
    • 59.95% Dev ApiControllers api(147:24), key(85:3), user(44:3), page(33:1), admin(20:12), wiki(22:5), v1(19:1), csrf(14:1), endpoint(7:6), 목록(9:2)
    • 43.47% Dev ApiResponse api(147:42), admin(20:33), json(1:42), page(33:8), wiki(22:5), v1(19:5), get(14:10), aha(19:3), 않는다(16:3), 403(10:9)
    • 41.69% ToDo-User-Nickname-Change api(147:31), user(44:59), key(85:9), nickname(1:55), admin(20:21), page(33:7), 않는다(16:21), wiki(22:4), account(10:14), seq(8:14)
    • 32.94% Dev AdminUIRoleMenu api(147:8), user(44:3), page(33:1), admin(20:14), wiki(22:1), aha(19:1), 않는다(16:1), get(14:1), seq(8:5), 표시(6:7)
  • Sister Wikis
    • 35.27% Aha00a:RecentChanges api(147:2), user(44:1), via(35:2), page(33:3), wiki(22:1), name(16:1), changes(9:3), date(10:1), minor(8:2), recent(7:3)
    • 33.39% Aha00a:PlantUML api(147:30), include(4:18), aha(19:1), pages(6:1), sync(3:3), content(4:1), request(2:3), and(2:2), select(1:3), insert(2:1)
    • 32.33% WhoHow:FrontPage api(147:2), user(44:1), via(35:2), page(33:2), wiki(22:1), name(16:1), changes(9:3), date(10:1), recent(7:4), minor(8:2)
    • 31.46% DringDring:FrontPage api(147:2), user(44:1), via(35:2), page(33:2), wiki(22:1), aha(19:1), name(16:1), changes(9:3), date(10:1), recent(7:4)
    • 31.41% AhariseWiki:FrontPage api(147:2), user(44:1), via(35:2), page(33:2), wiki(22:2), name(16:1), changes(9:3), date(10:1), recent(7:4), minor(8:2)
    • 30.08% Aha00a:AWS EC2 Replace Ssh Key key(85:20), name(16:5), 기존(8:5), key를(10:1), 생성(5:4), 추가(5:3), test(4:4), keys(6:1), 변경(4:3), 삭제(4:3)

10.3. Adjacent Pages

Control
≤ 32
all
1.0x
1.0x
80
-120
ON
Metrics
Nodes(visible/total)0/0
Links(visible/total)0/0
Avg degree0.00
Depth coverage0
Queue(fetch/graph)0 / 0
Zoom(scale)1.00x
Ctrl/⌘ + Scroll: Zoom
Root 1-hop 2-hop+